Skip to content

Stop shots and spawns from eroding the river banks - #3

Merged
mpasternak merged 15 commits into
mainfrom
fix-projectile-terrain-erosion
Jul 26, 2026
Merged

Stop shots and spawns from eroding the river banks#3
mpasternak merged 15 commits into
mainfrom
fix-projectile-terrain-erosion

Conversation

@mpasternak

Copy link
Copy Markdown
Contributor

Shots and newly spawned actors were destroying river terrain permanently. The damage was invisible in the sense that eroded land keeps killing the player - collision tests the course model, not the framebuffer - so it reads as dying on open water.

Fixed

Projectiles erased bank pixels. A two-pixel shot was drawn by storing its mask over the whole screen byte and cleaned up by writing plain water, both assuming the byte was entirely river. It is not: bullet_x is latched when the shot is fired while the river narrows above it, and bullet_hits_background only asks whether the two lit pixels meet land, so a byte holding both the shot and bank pixels passed the test and then lost its land. The dirty pass replays per-block deltas only, so on a straight bank the notch was never repaired. Draws now compose terrain XOR mask, matching how the player and the other crossing actors already render over mixed terrain, and cleanup restores real world bytes.

The tank splash landed eight pixels right of its target. Its aim point is a centre, clamped against the lane like the jet's centre, but tank_shell_x is a left edge for every consumer. The maximum target therefore put the splash's second byte exactly on the right bank edge column.

Ships and the helicopter spawned partly on land. All three raised their active flag through A and then called calc_safe_river_x[_wide], which takes Y in A - so they placed themselves using a scanline above the playfield. spawn_balloon and spawn_fuel compute X before raising the flag and were correct.

Cleanup

Three routines with no callers deleted; a dead branch in the projectile geometry removed (a zero pixel offset cannot equal seven, so the branch could only reach the result the other test already gave); the block-delta overflow fallback documented as a safety net, including the fact that render_v3_row_indexed has one live caller and its island half never executes at all despite the dirty_ prefix on its labels.

Verification

Reproduced and re-measured in ZEsarUX against the game's own block_bitmap_rows cache as an independent oracle, with a pre-fix control build and a no-shots noise floor:

  • terrain damage from projectiles: 71 events in 160 s to 0, on identical scanning
  • the two strongest pre-fix bullet geometries re-checked on frozen frames: 53 occurrences of the damaging geometry, 0 land-loss violations, screen bytes matching terrain XOR mask exactly
  • splash: 15 landings, no bank straddle, sprite now centred on its landing point
  • bridge band audited separately (162 audits, 0 mismatches), including frames where a span was destroyed
  • spawn placement: spawn-on-land 4.6 % to 0.8 % of spawns; the sampled-row check flips from "matches row 1" to "matches row 16" on every ship and helicopter spawn

Tried and reverted

Taking the destroyed road off the renderer's per-byte path looked like the bridge cost because its symbols appeared only in the slow window. It was not: that family is 1.16 % of instructions at most, and the change moved the window from 43.2 % to 42.0 % overrun frames while breaking the FUEL depot, which the fast path copies without its overlay. Reverted, with the numbers and the reasoning error written into docs/TODO.md so it is not tried again.

Not fixed

Resident sprites still eat island and bank edges with their lower rows: the safe X is sampled on one scanline while the sprites are 8 to 32 scanlines tall and span several course blocks. This PR fixes only the spawn-row half. The rest is written up as correctness item 1 in docs/TODO.md, including the disproof of the obvious theory (the river cannot meander into a latched X - a world-anchored sprite and the course advance together and the block index cancels) and a recommendation to fix placement rather than convert the writers, since placement costs nothing at runtime.

docs/TODO.md also now carries the profiling methodology, which was harder than expected: two builds cannot be put in the same scene because a single extra LFSR draw scrambles the spawn sequence, and an emulated-time anchor is correlated with the effect being measured because an overrunning frame delays the next halt.

🤖 Generated with Claude Code

mpasternak and others added 15 commits July 26, 2026 19:38
get_course_background_byte_indexed was orphaned by the run-fill rewrite of
rebuild_block_bitmap_row, which emits the five contiguous terrain runs
directly instead of classifying all thirty-two columns; its comment there
no longer needs to name the routine it replaced.

calc_river_center_col has no callers left, and
timex_next_attribute_row was an instruction-for-instruction duplicate of
timex_advance_object_row_fast.

The image size is unchanged because the page-aligned course tables in
state.asm absorb the freed bytes as padding; the removed symbols are gone
from the map file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prepare_transition_old_projectile_x masked the pixel offset twice and
branched on it being zero, but a zero offset cannot equal seven, so that
branch only reached the same width-1 result the cp 7 test already gives.
The routine now mirrors prepare_transition_new_projectile_x exactly.

Verified two ways: the emitted code for the two routines is byte-identical
apart from the low bytes of the four destination addresses, and simulating
the previous and current logic over all 256 inputs gives the same width and
column for every one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The count=255 marker and the complete edge renderer behind it read like a
working case for complex terrain, but the generator cannot reach them: a bank
edge moves at most four pixels per block, and an island opens one byte wide
and grows or tapers one byte per side per step, so consecutive blocks never
differ in the 16 byte pairs the delta list holds.

That leaves render_v3_row_indexed with one live caller, bridge repair, and
since update_course_feature clears the island on every block unless a fork is
in progress, its island half never runs at all. The dirty_ prefix on those
labels invites the opposite conclusion, so say so at the routine itself: the
scroll pass replays deltas in dirty_delta_replay and never enters here.

Kept rather than deleted so a future wider terrain feature degrades into the
complete renderer instead of drawing a truncated delta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A two-pixel shot was drawn by storing its mask raw and cleaned up by writing
plain water, both of which assume the whole screen byte is river. It is not:
bullet_x is latched when the shot is fired while the river keeps narrowing
above it, and bullet_hits_background only asks whether the two lit pixels
meet land, so a byte holding both the shot and bank pixels passes the test
and then loses its land pixels to the write. The dirty pass replays per-block
deltas only, so on a straight bank the notch was never repaired.

Draws now compose each byte as terrain XOR mask, matching how the player and
the other crossing actors already render over mixed terrain, which also keeps
a shot visible as a water-coloured hole when it crosses land. Cleanup switches
to transition_background=1 and restore_flying_shell_row reads real world bytes.
Restoring through fill_world_background_rect is safe over an intact bridge
because get_world_terrain_byte models the road as a world layer and rebuilds
it, rather than skipping those rows as the water fill had to.

The splash uses write_world_sprite_2xn for the same reason: it is an opaque
16-pixel sprite and could straddle the bank edge byte.

Reproduced beforehand on the autopilot bench by comparing the display file
against the game's own block_bitmap_rows cache: 47 damage events in ~125 s,
erased runs of 1 to 5 pixels at bank-edge bytes, persisting up to 4.08 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tank_shell_target_x is a centre - it is clamped against the lane bounds the
same way the jet's centre is - but land_tank_shell copied it into
tank_shell_x, which state.asm documents as a left edge and which every
consumer treats as one, including the splash draw and its cleanup rect.

get_pixel_lane_bounds reserves 16 pixels at the right bank (E = 8*right_col
- 16) so a 16-pixel object positioned by its left edge fits. The extra eight
pixels ate half that reserve, so the maximum target put the splash's second
byte exactly on the right bank edge column. Converting the centre to an edge
once at the landing point fixes the alignment and puts the sprite back inside
the reserved lane; the left bank always had two bytes of clearance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documented command line hangs before the ZRCP port opens unless the
video output is disabled, which reads as a broken emulator rather than a
missing flag. Record --vo null next to it, plus the audio flags that keep a
host speaker quiet during unattended runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the finished correctness and cleanup items into a Settled section with
the evidence, and replaces the "~0-0.5 % frame overruns" premise: measured
over three windows per build the bench ranges 0.0 % to 7.8 % overruns on
unmodified code, driven by how many actors are live in the sampled window. A
single window proves nothing, so the note now says to aggregate several and
always run a control build through the same protocol.

Adds two follow-ups the investigation exposed: whether resident fixed-X
sprites erode banks by the same mechanism the projectiles did (balloon_x and
ship0_x are latched at spawn and drawn opaquely, and the scanner used for the
projectile bug would have masked exactly this case), and hoisting the per-row
terrain fetch the fix introduced, which is redundant because all eight
scanlines of a course block share one terrain row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The performance question the correctness fix raised is now measured. The only
window pair with an identical actor population puts the cost at about two
percentage points of overrun frames; the pooled figures are dominated by one
window that caught a bridge destruction, which is a pre-existing worst case,
not a cost of the fix. Bridge staging therefore becomes the top performance
item on evidence rather than on guesswork.

Removes the fetch-hoisting item I had added. Its premise was that a four-row
bullet re-resolves the course block four times, but resolve_course_block_index
already caches the index and the rows left in it for exactly this access
pattern, and the profile confirms most calls take that path. What is left to
win is near 1 % of a frame, and taking it needs the cache to be conditional on
bridge-band rows and the FUEL column - poor value against code whose
correctness was just established empirically.

Also records the measurement methodology, including two traps: enemy spawns
follow an LFSR that a single extra draw scrambles, so two builds cannot be put
in the same scene, and an emulated-time anchor is correlated with the effect
being measured because an overrunning frame delays the next halt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Destroying a span sets destroyed_road_active, which update_destroyed_road
holds until the band scrolls past the bottom of the playfield - up to 76
frames when a bridge is blown near the top. Throughout that time
fill_world_background_rect routed every one of the band's sixteen rows
through the per-byte query engine, so any sprite cleanup touching the band
paid it. That measured as a sustained half-rate stretch: a profiling window
covering one destruction reported 43 % of frame boundaries with no idle halt
while still showing 56 % idle overall, the signature of heavy frames
alternating with frames spent waiting for the next interrupt.

Only two of those rows actually differ from plain terrain - the black edge
lines at band rows 1 and 14 - so the other fourteen now take the fast
block-bitmap copy. An intact span still owns all sixteen. The Timex build is
unaffected: it never treated a destroyed road as special.

The fast path copies terrain without the FUEL overlay that the per-byte query
applies, so those fourteen rows lose it. That matches what the fast path
already does for every other row of the playfield, and FUEL can share a
bridge board, so the case needs checking in the emulator alongside this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The destroyed-road slow path was the dominant bridge cost and is fixed, so it
moves to Settled. Staging the destroy frame stays on the list at its real
size - one or two frames out of the seventy-six the band lives for - with a
warning the original plan missed: a half-finished rebuild leaves the band half
destroyed while the world model carries one bridge_active bit for all sixteen
rows, so the model must be split by row against the restore cursor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spawn_ship0, spawn_ship1 and spawn_helicopter set their active flag through A
and then called calc_safe_river_x[_wide], which takes Y in A - so all three
asked for a safe X at row 1, above the playfield and two course blocks newer
than the row the actor actually appears on. spawn_balloon and spawn_fuel do
not have the bug because they compute X before raising the flag.

Replaying both candidate formulas against the latched X at every spawn
identifies the sampled row unambiguously: ship0 matched row 1 on 15 of 15
spawns and row 16 on none, ship1 16 of 16, the helicopter 9 of 9. Measured
over 238 spawns, ten percent of ship spawns put the actor partly on land.

This is one of two causes of resident sprites eating terrain; the other is
that the safe X is sampled on a single row while the sprites are 8 to 32
scanlines tall and span several course blocks. That one is not fixed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suspicion is confirmed and much bigger than expected: all five resident
actors damage terrain, on both the draw and the cleanup path, 18 513 damaging
writes in 200 s, and the damage is permanent whenever the affected column is
shared by two adjacent blocks. It is also not cosmetic - collision tests the
model rather than the framebuffer, so eroded land stays lethal.

Writes down the disproof of the theory I had recorded, because it is the
obvious theory and someone will have it again: the river cannot meander into a
latched X, since a world-anchored sprite and the course advance together and
the block index cancels. The real cause is that the safe X is sampled on one
row while the sprites are up to 32 scanlines tall and span several blocks.

Also corrects the claim that the FUEL depot was immune. The world query makes
other sprites compose over the depot; it does nothing for the depot's own
writer, which is the single worst offender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit dc667fa. It was wrong on both counts.

It broke the FUEL depot. The fast path copies block_bitmap_rows raw, without
the overlay get_world_background_byte applies, so a depot overlapping a
destroyed band lost whole bytes on band row 0 whenever another sprite's
cleanup restored the world there. Measured with the only difference between
the two builds being this hunk: 41 of 200 frames lost depot pixels, against
0 of 200 with it reverted.

And it bought nothing. The window that motivated it still measures 42 %
overrun frames afterwards, against 43.2 % before. The premise was wrong: I
read the slow band fill as the cost because its symbols appeared only in the
bad window, without checking their magnitude - fill_world_background_byte_loop
is 0.30 % of instructions, which cannot produce a 43 % overrun rate. The
structural reason there was nothing to win: fill_world_background_rect runs
only from sprite cleanup, the bridge corridor bans most spawns, and
finish_player_transition_direct already switches the player to a full opaque
redraw over the band, so a destroyed road usually sees no cleanup at all.

The real cost of destroying a bridge is still unattributed. What is known is
that it is felt at the explosion itself, not spread over the following second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the claim that the destroyed-road slow path was the bridge cost, and
the changelog entry for a change that no longer exists. Writes the attempt up
in the rejected list with the numbers, including the reasoning error worth
remembering: symbols that appear only in the bad window are a hint, not a
cause, and the magnitude has to be checked before building on them.

Also corrects the bridge item. Playtesting places the stutter at the explosion
rather than in the second that follows, and staging does not need the world
model split by row after all - flipping the flags atomically and staging only
the bitmap is enough, which is what the agreed crumble effect will do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mpasternak
mpasternak merged commit ec5bf76 into main Jul 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant